昨天我們靠 Buffer Overflow 覆蓋 Return Address,讓程式跳進 win() 拿到 Shell。整個流程很漂亮,但有一個前提:Binary 裡面得先有一個 win() 等我們跳。
如果程式裡根本沒有這種東西呢?
既然我們已經能控制 RIP,那能不能乾脆自己準備一段 CPU 可以執行的 Machine Code,然後讓 RIP 跳過去?可以,這就是 Shellcode。
前面介紹 Assembly 的時候提過,C Code 最後會經過 Assembly 變成 Machine Code,再由 CPU 執行。Shellcode 本質上就是一小段可以直接被 CPU 執行的 Machine Code——把它放進 Memory,讓 RIP 指過去,CPU 就會開始跑。
Shellcode 不一定真的要開 Shell,它可以讀檔案、建 Socket、做 System Call。只是早期 Exploit 很常用這種方式拿 /bin/sh,所以才叫 Shellcode。
在 Linux x86-64 中,要執行一個程式可以用 execve():
execve("/bin/sh", NULL, NULL);
但 Shellcode 裡面通常不會直接呼叫 execve(),因為 Shellcode 本身沒有 libc、沒有 main()、沒有正常的程式結構。我們真正需要的是直接跟 Linux Kernel 溝通——透過 syscall。
User Space 的程式要做 open、read、write、execve 這些事情,最後都要透過 System Call 進 Kernel。在 Linux x86-64 裡,System Call 的參數用 Register 傳遞:
RAX = syscall number
RDI = argument 1
RSI = argument 2
RDX = argument 3
execve 的 syscall number 是 59,所以要達成 execve("/bin/sh", NULL, NULL) 的效果,需要:
RAX = 59
RDI = 指向 "/bin/sh" 的位址
RSI = 0
RDX = 0
最後執行 syscall 指令就行了。
把上面的邏輯寫成 Assembly,大概會長這樣:
xor rsi, rsi ; RSI = 0
push rsi ; 把 NULL 推上 Stack(當字串結尾)
mov rbx, 0x68732f2f6e69622f
push rbx ; 把 "/bin//sh" 推上 Stack
mov rdi, rsp ; RDI 指向 Stack 上的 "/bin//sh"
xor rdx, rdx ; RDX = 0
mov rax, 59 ; syscall number = execve
syscall
這裡 0x68732f2f6e69622f 就是 /bin//sh 按 Little Endian 排列的結果。多一個 / 對 Linux 路徑沒有影響,但剛好湊成 8 Bytes,在 x86-64 裡比較方便處理。
要注意的是,上面那段仍然是 Assembly,CPU 真正執行的是 Machine Code。Assembly 需要經過 Assembler 轉換:
Assembly → Assembler → Machine Code
所以真正的 Shellcode 長得比較像一串 Bytes:48 31 f6 56 48 bb ...,而不是我們看到的 xor rsi, rsi。
好在我們不用每次都手刻。pwntools 的 shellcraft 可以直接幫我們產生常見的 Shellcode:
from pwn import *
context.arch = "amd64"
shellcode = asm(shellcraft.sh())
shellcraft.sh() 產生 spawn /bin/sh 所需的 Assembly,asm() 再把它轉成 Machine Code。兩步合在一起,出來的就是可以直接執行的 Shellcode。
如果想看看到底產生了什麼 Assembly:
print(shellcraft.sh())
或者反過來把 Machine Code disassemble 回去:
print(disasm(shellcode))
前面學的 Assembly、Machine Code、objdump、GDB、pwntools 到這裡其實全部串在一起了。
pwntools 也可以幫我們直接跑 Shellcode 看看行不行:
from pwn import *
context.arch = "amd64"
shellcode = asm(shellcraft.sh())
p = run_shellcode(shellcode)
p.interactive()
執行 python3 test.py,如果成功會進入 Interactive Mode,可以直接打 id 或 whoami。能跑起來就代表 CPU 確實在執行我們產生的 Machine Code,最後呼叫了 execve("/bin/sh")。
這才是今天真正重要的地方。
昨天 ret2win 的 Payload 是 Padding 加上 win() 的位址。那如果我們不跳 win(),改成把 Shellcode 塞進 Buffer,然後讓 RIP 跳到 Buffer 的開頭呢?
+------------------+
| Shellcode | ← RIP 跳到這裡
| ... |
+------------------+
| Padding |
+------------------+
| Saved RIP | → Buffer 的位址
+------------------+
流程就變成:Buffer Overflow → 控制 RIP → RIP 指向 Buffer → CPU 開始執行我們塞進去的 Shellcode → /bin/sh。
這就是 Stack Shellcode Injection。
不過有一個實際問題:RIP 必須非常準確地跳到 Shellcode 開頭,差幾個 Byte 就會 Crash。
早期有一個經典技巧叫 NOP Sled。NOP(No Operation)是一個 CPU 什麼事都不做、直接往下執行的指令,x86 的 Opcode 是 0x90。如果在 Shellcode 前面塞一大段 NOP:
+----------------------+
| 0x90 0x90 0x90 ... | ← 只要跳進這段就行
+----------------------+
| Shellcode |
+----------------------+
| Padding |
+----------------------+
| Return Address |
+----------------------+
RIP 不需要剛好跳到 Shellcode 開頭,只要落在 NOP Sled 的任何位置,CPU 就會一路 NOP 滑到 Shellcode 開始執行。所以 Payload 常常會是 NOP Sled + Shellcode + Padding + Return Address 這種結構。
Shellcode 看起來很強,但實際上有不少限制。
Architecture:x86 的 Shellcode 不能直接拿去 AMD64 跑,ARM 的也不能拿去 x86 跑。所以 pwntools 裡才要先指定 context.arch = "amd64"。
Size:有些漏洞能寫進去的空間非常有限,可能只有 20、40 Bytes。Shellcode 越短越好,這也是為什麼 Shellcode 裡面常常看到 xor rax, rax 這種比較短的寫法,而不是用更直覺但更長的指令。
Bad Characters:有時候某些 Byte 不能出現在 Payload 裡。最常見的就是 \x00(NULL Byte),因為很多 C 的字串函式遇到 \x00 就認為字串結束了,Payload 會直接被截斷。之後如果遇到 Badchars 類型的題目,就會一直在處理這件事。
Position Independent:我們通常不知道 Shellcode 最後會被放到哪個 Address,所以 Shellcode 最好不要寫死絕對位址,而是用 RSP、Relative Address 這些方式取得需要的資料。
看到這裡可能會發現一件事——我們剛剛的攻擊假設 Shellcode 放在 Stack 上,RIP 跳過去就能執行。但現代 Linux Binary 真的允許 Stack 上的資料被 CPU 當成 Instruction 跑嗎?
通常不行。現代 Binary 有一個保護機制叫 NX(Non-Executable):Stack 可以放資料,但不能拿來執行 Code。所以 Shellcode Injection 這種經典手法在現代環境中通常會直接被 NX 擋掉。
除了 NX 之外,我們還會遇到 Stack Canary、PIE、ASLR、RELRO 這些保護機制,也就是每次打 Pwn 題第一個會跑的 checksec 在告訴我們的事情。
既然 NX 通常會擋掉 Stack 上的 Shellcode,那為什麼還要學?
因為 Shellcode 是真正把 CPU 執行 Machine Code、System Call、Register、Memory、RIP Control 這些概念串在一起的關鍵。而且某些 CTF 題目裡仍然會出現 RWX Memory、Executable Stack、ORW、seccomp 這類需要寫 Shellcode 的情境。
更重要的是,之後學 ROP 的動機其實就是從這裡來的:因為 NX 不讓我們直接執行自己的 Shellcode,所以才開始利用 Binary 裡已經存在的 Instruction 來拼湊出想要的行為。整條發展脈絡是連貫的:
控制 RIP → ret2win → Shellcode → NX 擋住 → ROP
今天從「利用 Binary 裡現有的 Code」走到「自己準備 CPU 要執行的 Code」。用 pwntools 的話,產生 Shellcode 只需要:
context.arch = "amd64"
shellcode = asm(shellcraft.sh())
跟 Buffer Overflow 結合的話就是:把 Shellcode 塞進 Buffer,Overflow 覆蓋 Saved RIP 讓它指向 Buffer,CPU 執行 Shellcode,拿到 Shell。
到這裡看起來似乎已經可以為所欲為了,但現代 Binary 當然不會這麼輕易讓我們成功。下一篇來看每次打 Pwn 題第一個會跑的 checksec 到底在告訴我們什麼——NX、Stack Canary、PIE、ASLR、RELRO,一個一個拆開看。